#!/usr/bin/python
"""This is a trade verifier for the BGG Aussie Maths trade.

It runs as a CGI service and provides feedback on a variety of errors
and problems with want lists submitted to the service.
"""

import cgi
import csv
import datetime
import json
import re
import sys
import traceback
import StringIO

form = cgi.FieldStorage();

#datafile = "/usr/local/rattus.net/data/aussie-trade-jan-2012.txt"
#datafile = "/usr/local/rattus.net/data/aussie-trade-jan-2014.txt"
#datafile = "/usr/local/rattus.net/data/aussie-trade-may-2014.txt"
#datafile = "/usr/local/rattus.net/data/sydney-trade-oct-2014.txt"
#datafile = "/usr/local/rattus.net/data/cancon-trade-jan-2015.txt"
#logfile = "/usr/local/rattus.net/data/tradelogs.txt"


def ReadConfig():
  """Reads the JSON config file object from disk.

  Returns a dictionary of config objects. If some of the necessary ones
  can't be found, the dictionary will contain a key 'error' with some
  sort of message, and the system should abort.
  """

  # TODO: replace the hard coded path to the config file with something
  # based on script location.
  config_file = "/usr/local/rattus.net/data/checktrades-config.json"
  try:
    config = json.loads(open(config_file, "r").read())
  except IOError:
    config = { "error" : "IOError opening config file." }
    return config

  necessary_fields = [ "datafile", "logfile", "trade_name" ]
  for field in necessary_fields:
    if field not in config:
      if "error" not in config:
        config["error"] =""
      config["error"] = (config["error"] +
                         "required field %s missing from config. " % field)

  return config


# logic for handling the game data

def ReadCodes(filename):
  "Read the complete list of codes in from a datafile and sort them"

  # all codes here.
  codes = {}
  # a dict mapping all valid numbers to their associated name string.
  numberlist = {}
  # a dict mapping all valid names to the list of numbers that have them.
  namelist = {}
  # a dict mapping each BGG user to a set of all the codes they
  # own, so we can check that people only do their own codes, and
  # don't miss any.
  user_codes = {}
  # a dict mapping each valid code to the user who listed it.
  user_by_code = {}

  fh = open(filename, "r")
  # add support for skipping comment lines in our data file.
  def skipcomments(iter):
    """Skip comment lines in file (starting wih #)."""
    for line in iter:
      if line[0] == "#":
        continue
      yield line

  csvfh = csv.reader(skipcomments(fh))
  headerline = csvfh.next()

  for row in csvfh:
    rowd = dict(zip(headerline, row))

    code = rowd["mtcode"]
    codes[code] = rowd["objectname"]
    bits = code.split("-")
    numberlist[bits[0]] = bits[1]

    # since we can have duplicate names, we'll map names to a list of
    # the numbers sporting them.
    if bits[1] not in namelist:
      namelist[bits[1]] = []
    namelist[bits[1]].append(bits[0])

    username = rowd["username"]
    if username not in user_codes:
      user_codes[username] = []
    user_codes[username].append(code)
    user_by_code[code] = username

  # convert this to a set.
  for user in user_codes:
    user_codes[user] = set(user_codes[user])

  return { "codes" : codes,
           "bynum" : numberlist,
           "byname" : namelist,
           "byuser" : user_codes,
           "userbycode" : user_by_code,
           "mincode" : min(map(int,numberlist.keys())),
           "maxcode" : max(map(int, numberlist.keys())) }

def CheckCode(code, data):
  "Check whether a supplied code is a valid code for the maths trade"
  # Let's be optimistic first.
  if code in data["codes"]:
    return True, ""

  if code == "LIMIT":
    return True, "limit"

  if re.match(r"\%[-A-Za-z0-9]+", code):
    return True, "group"

  # We'll special case cash bids here.
  m = re.match(r"\$([0-9]+)$", code)
  if m:
    if int(m.group(1)) >= 0:
      return True, "money"
    else:
      return False, "Money amounts must be non-negative"

  m = re.match(r"^([0-9]+)-([A-Z0-9]+)$", code)
  if not m:
    return False, "Unable to parse code. It must be of the form NNNNNNN-AAAAA %AAAAA or $NN"

  codenumtext = m.group(1)
  codetext = m.group(2)
  codenum = int(codenumtext)
  warning = ""
  if codenum < data["mincode"] or codenum > data["maxcode"]:
    warning = "Code number out of range %d-%d\n" % (data["mincode"],
                                                  data["maxcode"])
  elif codenumtext not in data["bynum"]:
    warning += "Code number is %s not valid" % codenumtext + "\n"
  else:
    # code number _was_ found.
    realname = data["bynum"][codenumtext]
    warning += "Code %s doesn't match name. Did you mean %s?\n" % (
      codenumtext, codenumtext + "-" + realname)

  if codetext not in data["byname"]:
    warning += "Code name %s is not valid" % codetext + "\n"
  else:
    # code text _was_ found.
    warning += ("Did you mean one of these codes for %s: " % codetext)
    warning += " ".join(data["byname"][codetext]) + " ?"

  warning = warning.strip("\n")
  return False, warning


def VerifyList(tradelist, data):
  "Verified each line of the tradelist and generate a report"

  bgguser = None

  errors = { "all" : False, "line": False }
  had_errors = False
  line_count = 0
  money_bids = 0
  limit_set = False
  report = StringIO.StringIO()

  groups_defined = set()
  group_items = {}
  groups_used = set()
  left_items = set()
  # everything that appears on the right
  right_items = set()
  # the set of all items wanted.
  wanted_items = []
  # overrides defined for the checker
  overrides = { }

  def AddError(*terms):
    print >>report, "<span class=error>", " ".join(terms), "</span>"
    errors["all"]  = True
    errors["line"] = True

  def AddInfo(*terms):
    print >>report, "<span class=info>", " ".join(terms), "</span>"



  for line in re.split(r"\r\n|\r|\n", tradelist):
    line = line.strip()
    if not(line):
      print >>report
      continue

    print >>report, cgi.escape(line)
    # comments.
    if re.match("^\s*#", line):
      # check for and parse overrides.
      m = re.match(r"\s*#\s*OVERRIDE\s+(.*)", line)
      if m:
        override_tokens = m.group(1).split()
        if not override_tokens:
          AddError("Invalid OVERRIDE line. Use '# OVERRIDE command' or "
                   "'# OVERRIDE command arg1 arg2': ",
                   cgi.escape(line.strip()))
        else:
          # defines all the valie override verbs.
          OVERRIDE_VERBS = [ "DUPSOK" ]
          if override_tokens[0] in OVERRIDE_VERBS:
            overrides[override_tokens[0]] = override_tokens[1:]
          else:
            AddError("Invalid OVERRIDE command:",
                     cgi.escape(override_tokens[0]),
                   "valid commands are:", " ".join(OVERRIDE_VERBS))
      # skip further processing of the comment line.
      continue

    # from here, this is some sort of interesting line.
    line_count += 1
    left_money = False
    right_money = False
    errors["line"] = False

    #
    # Check initial name part.
    #

    m = re.match(r"\(([^)]+)\)\s+(.*)", line)
    if not m:
      AddError("Each trade line must begin with a BGG username in () followed by at least one space.")
      continue
    if not bgguser:
      bgguser = m.group(1)
    else:
      if m.group(1) != bgguser:
        AddError("BGG username in () doesn't match earlier lines.")
        # nonfatal.

    line = m.group(2)
    bits = line.split(":")
    if len(bits) != 2:
      AddError("Line does not contain exactly one :")
      continue

    left_codes = bits[0].split()
    if len(left_codes) != 1:
      AddError("More than one token to the left of the :")
      continue

    #
    # Check the left hand side.
    #

    left_code = left_codes[0]
    valid, left_message = CheckCode(left_code, data)
    # TODO, check up on the various other types of code here.
    if not valid:
      AddError("On left side:", cgi.escape(left_code),
               "is invalid.", left_message)

    if not left_message:
      if (bgguser not in data["byuser"] or
          left_code not in data["byuser"][bgguser]):
        AddError("Item", cgi.escape(left_code), "was not listed by user",
                 cgi.escape(bgguser),
                 "(listed by %s)!" % data["userbycode"].get(left_code, "None"))

      if left_code in left_items:
        AddError("Item", cgi.escape(left_code), "has multiple want lists.")
      else:
        left_items.add(left_code)

    if left_message == "money":
      left_money = True

    if left_message == "group":
      if left_code in groups_defined:
        AddError("Duplication protection group", cgi.escape(left_code),
                 "defined more than once!")
      groups_defined.add(left_code)
      group_items[left_code] = []

    if left_message == "limit":
      limit_value = bits[1].split()
      limit_error = False
      if len(limit_value) != 1:
        limit_error = True
      else:
        valid, message = CheckCode(limit_value[0], data)
        if not valid or message != "money":
          limit_error = True

      if limit_error:
        AddError("A LIMIT must be followed by a dollar value")
        continue

      if limit_set:
        AddError("LIMIT was set more than once")
      limit_set = True


    #
    # Iterate through the right hand side.
    #

    all_right_codes = set()
    right_codes = bits[1].split()
    if len(right_codes) == 0:
      AddInfo("Warning. Empty want list.")

    for code in right_codes:
      safecode = cgi.escape(code)
      if code == left_code:
        AddError("You want game", safecode, "for itself.")
        continue

      valid, message = CheckCode(code, data)
      if not valid:
        AddError("On right:", safecode, "is invalid.", message)
        continue

      if not message:
        right_items.add(code)
        if left_message != "group":
          wanted_items.append((code,line_count))

      if not message and code in all_right_codes:
        AddError("Code", safecode, "appears multiple times.")
      else:
        all_right_codes.add(code)

      if message == "money":
        if right_money == True:
          AddError("Multiple dollar amounts on right side.")
        right_money = True

      if message == "group":
        if left_message == "group":
          AddError("A %GROUP was used on the left and right side.")
        groups_used.add(code)

      if message == "limit":
        AddError("You cannot have a LIMIT on the right")

    if left_money and right_money:
      AddError("Money on left and right.")
    if left_money:
      money_bids += 1

    if not errors["line"]:
      AddInfo("ok.")

  print >>report

  # we should warn differently if there are multiple money bids, vs if there
  # are just one.
  if money_bids > 1 and not limit_set:
    AddError("Error. Multiple money bids without a LIMIT will have a default LIMIT of the largest bid. Please add a LIMIT")
  if money_bids == 1 and not limit_set:
    AddInfo("Warning. One money bid made without a LIMIT.")

  if not money_bids and limit_set:
    AddInfo("Warning. A limit was set but no money bids were made. This has no effect.")

  if money_bids and limit_set and limit_value == 0:
    AddError("Money bids were made, but the limit of $0 breaks them.")

  items_listed = set(left_items)
  if (items_listed and bgguser in data["byuser"] and
      items_listed != data["byuser"][bgguser]):
    AddInfo("%s posted no want lists for the following posted items:" % bgguser,
            " ".join(data["byuser"][bgguser] - items_listed))

  unused_groups = groups_defined - groups_used
  undefined_groups = groups_used - groups_defined
  if unused_groups:
    AddInfo("Warning. Unused groups:",cgi.escape(" ".join(unused_groups)))
  if undefined_groups:
    AddError("Error. Undefined groups were used in wantlists:",
             cgi.escape(" ".join(undefined_groups)))
  left_and_right = left_items.intersection(right_items)
  if left_and_right:
    AddError("Error. Items appear both left and right sides:",
             cgi.escape(" ".join(left_and_right)))


  # check for the existence of multiple similar-looking items in want lists
  # e.g. that someone is likely missing a duplicate-protection group.

  wanted_codes = {}
  for (item, line_number) in wanted_items:
    (number,code) = item.split('-')
    # we need to disambiguate different objects with the same code here. 
    full_code = (code, data["codes"][item])
    if full_code not in wanted_codes:
      wanted_codes[full_code] = { "items": [], "lines": set() }
    wanted_codes[full_code]["items"].append(item)
    wanted_codes[full_code]["lines"].add(line_number)

  for full_code in wanted_codes:
    if "DUPSOK" in overrides and full_code[0] in overrides["DUPSOK"]:
      # don't check against this code at all.
      continue
    itemlist = list(set(wanted_codes[full_code]["items"]))
    if len(itemlist) > 1 and len(wanted_codes[full_code]["lines"]) > 1:
      AddError("Error. The following similar items appear in multiple wantlists "
              "so you could be that guy who receives multiple copies in the trade. "
               "Use a duplicate protection group to fix this.",
              cgi.escape(" ".join(itemlist)))


  #  m = re.match("
  if not errors["all"]:
    print >>report
    print >>report, "<b>All lines verified okay. Please submit via geekmail to thepackrat </b>"
    all_okay = True
  else:
    print >>report
    print >>report, "<b>Some errors were found. Correct and reverify.</b>"
    all_okay = False

  return report.getvalue().replace("\n", "<br>\n"), all_okay

# core functionality here.
# TODO: make this neatly into a function sometime.

if len(sys.argv) > 1:
  argv = sys.argv[1:]
  if argv[0] == "--full":
    full_report = True
    argv = argv[1:]
  else:
    full_report = False
  if len(argv) != 2:
    print "Usage: checktrades datafile tradefile"
    sys.exit(1)

  datafile = argv[0]
  tradelist = open(argv[1], "r").read()
  print_html_report = False
  save_log = False
  print "Checking file %s:" % (argv[1]),
  config = {}
else:
  config = ReadConfig()
  if not "error" in config:
    logfile = config["logfile"]
    datafile = config["datafile"]
  tradelist = form.getfirst("tradelist", "")
  print_html_report = True
  save_log = True

if tradelist:

  try:
    # TODO: rewrite all this sensibly into functions so the flow can
    # be followed.
    if "error" in config:
      raise ValueError
    if save_log:
      log = open(logfile, "a")
      log.write(datetime.datetime.now().isoformat(" ") + "\n")
      log.write(tradelist + "\n\n")
    data = ReadCodes(datafile)
    verify_message, verified_okay = VerifyList(tradelist, data)

  except:

    verify_message = "Error in verification service. Please geekmail thepackrat with details of what you tried. We apologise for the inconvenience."
    verified_okay = False

    exc_type, exc_value, exc_traceback = sys.exc_info()
    trace_lines = traceback.format_exception(exc_type, exc_value, exc_traceback)

    if save_log:
      log = open(logfile, "a")
      log.write(datetime.datetime.now().isoformat(" ") + "\n")
      log.write("ERROR\n")
      log.write(tradelist + "\n")
      log.write(''.join('!! ' + line for line in trace_lines) + "\n")
      if "error" in config:
        log.write("!! " + config["error"] + "\n")
    else:
      print ''.join('!! ' + line for line in trace_lines)
      if "error" in config:
        print "!! " + config["error"]


else:
  verify_message = "No tradelist entered."

warning_message = """
<font size=+1 color=red><b>
<br> The tool doesn't yet have the final item list for the 2012 year-end aussie
maths trade. Please check back after the codes are finalised.
</b></font>
<br>
"""

warning_message = """
<font size=+1 color=red><b>
<br> The 2012 end of year maths trade tool is just being brought up. You
may experience oddities until I'm done. </b></font>
<br>
"""


warning_message = ""

if print_html_report:

  if "trade_name" in config:
    trade_name = config["trade_name"]
  else:
    trade_name = "Demo"

  print "Content-type: text/html"
  print

  print """<html>
<head><title>BGG Maths trade list verifier</title>
<style type="text/css">
.error {
  font-weight: bold;
  color: #ff0000;
}
.info {
  font-weight: bold;
  color: #000000;
}
</style>
</head>
<body>

<h1> %s </h1>


<div> <P> The purpose of this tool is to give quick feedback on some of the
simple errors that can creep into a tradelist. Once your tradelist
verifies okay here, submit it via geekmail as normal. Report issues
via geekmail to thepackrat.
</div>

<div class="warning">
%s
</div>

<div class="report">
<tt>
%s
</tt>
<div>

<br><br>

<form method="post" action="checktrades.py">
Paste a copy of your trade list below:<br>
<textarea cols=80 rows=10 name="tradelist">
%s</textarea><br>
<input type=submit value="verify">
</form>

</body>

""" % (trade_name, warning_message, verify_message, cgi.escape(tradelist))

else:
  # when print_html_report is not set.
  if full_report:
    print warning_message
    print verify_message
  else:
    if verified_okay:
      print "Okay"
    else:
      print "Some errors found"

  if verified_okay:
    sys.exit(0)
  else:
    sys.exit(1)
